任务调度系列(1) | Celery篇

0x00 : 前言

本人接触任务调度相关领域没多久,之前主要做的还是图形学相关的应用研发,机缘巧合才开始接触的任务调度。网上大部分的任务调度方案动辄涉及海量任务,超高并发,但是很多早期的多任务处理需求,没有条件(这个条件指的是人和机器)也没有必要(这个必要指的是需求量的大小)上这些方案。随着项目需求的持续迭代,技术规模才逐渐变得庞大,技术方案也更加成熟。一般来说技术方案从稚嫩到成熟,并不是一蹴而就的(当然如果研发很有经验,可能是可以直接一步到位的)而且也很少有人记录分享这种技术随业务进化的过程。这段时间自己摸索花了不少时间,希望能记录下来,避免后续大家踩坑。

0x01 : 需求描述

直接进入正题,有这样一个需求,需要离线使用文本生成图像的服务(也就是输入一段文本,然后生成文本所描述的图像,大约10s出一张图),目标给定100w段文本,生成100w张图片。算了一下,一台机器不眠不休一共需要115.74天,项目黄了之前估计都没跑完。所以最简单的想法就是加机器,不过预算有限,只加到了10台机器,这样一算,平均只要11.6天就能跑完了,看起来还是可以接受的。

于是你每台机器手动分配了10w的文本去跑图片,结果发现出问题了。你分析总结了一下,发现核心其实就是两个问题:

1.有机器/代码偶尔会卡死,你需要时不时观察机器是否可用,不可用还要手动重启。
2.每台机器的配置不太一样,最慢的机器跑10w需要20多天,所以你最多要等20天。

总结提炼出来就是要解决两个问题:

1.负载均衡:调整任务分配策略以确保最后所有机器能够用差不多时间运行完所有任务,不会有机器一直空闲。
2.失败重试:不需要时刻观察机器状态,能够自动将失败的任务发送到当前可运行的机器进行重试,超过重试次数后才确认最终失败。

0x02 : 基于数据库的任务调度方案

这时候你又想到了一个绝妙的方案,弄一个机器作为master来统一管理所有机器的状态以及所有任务的分配,这也是中心化任务分配的思路。master机器上用数据库存储了100w段文本转图像任务(任务状态为WAITING),然后让10台机器去数据库中取待运行的任务,同时更新任务的状态(WAITING -> RUNNING),直到任务运行完毕,将任务的状态更新为COMPLETED,这样其他机器就不会重复运行该任务了。经过这套机制,基本上解决了负载均衡的问题,因为空闲的机器会自己去master机器拉取任务运行,这也是最基本的负载均衡算法Round Robin。

flowchart TB
    subgraph Master["Master 节点"]
        direction LR
        MasterNode[Master 节点任务调度逻辑]
    end

    subgraph DBGroup[" "]
        direction LR
        DB[(MySQL 数据库<br/>存储 100w 任务<br/>)]
        Note["任务状态类型<br/>WAITING / RUNNING / COMPLETED"]
        DB -.- Note
    end

    subgraph Worker["Worker 节点(共10台)"]
        direction LR
        Worker0[Worker 0]
        Worker1[Worker 1]
        Dots[…]
        Worker9[Worker 9]
    end

    %% Master 与数据库
    MasterNode <--> DBGroup

    %% Workers 与 Master 节点
    Worker0 -- 空闲中,拉取Status==WAITING的任务 --> MasterNode
    Worker1 -- 任务结束,写回结果,Status置为COMPLETED --> MasterNode
    Worker9 -- 开始运行,Status置为RUNNING --> MasterNode

还剩下一个失败重试的问题。失败分为两种情况:1.重试一下也可以正常运行 2.重试多次也不行。这时候你就需要有个定时服务,去轮询数据库中任务的状态,比如长时间任务的状态(RUNNING)一直没更新,那就认为任务超时失败了(TIMEOUT),或者任务的状态是FAILED,那就重试任务。如果失败超过3次就默认这个任务最终是失败的。

这时候你就会发现,10台机器上除了运行文本转图像的代码之外,还加上了从数据库中获取任务和去数据库存储任务结果的功能。我们把10台机器上的这些读写任务的功能统称为Worker,而生成待运行的任务并填充到数据库中的功能叫做Broker,往数据库中存储任务结果的功能叫做Backend。随着数据量变大,你发现使用数据库去读写任务状态,会导致查询速度很慢,所以你考虑将Broker和Backend的存储方案换成了Redis。

0x03 : 相识Celery

这时候你又发现不管是哪一个Worker,从Broker获取任务和去Backend存取任务结果的代码好像重复了,你就会想有没有什么成熟的框架解决这个问题,于是就发现了Celery。而这一切思考路径都显得非常自然,合情合理,这也是为什么我第一次看到Celery这个框架的时候,我就觉得找对了。

可以看到下图中,图片来源Unlocking Scalable Task Processing with Celery, Google Cloud Pub/Sub, and Google Cloud Storage: Part 2,Celery的Broker是支持RabbitMQ和Redis的,用来接收客户端发来的任务请求,并将这些待运行任务存储到任务队列里面。和我们之前手动使用数据库的方式不同,使用队列存储待运行任务的方案会更加灵活,方便后续对任务进行优先级调度。Celery的Worker则通过Pull的方式从Broker中获取待运行的任务,并将最终结果存储到Backend里面,而Backend一般常用的也是RabbitMQ和Redis,当然如果有持久化的需求,就得上MySQL或者PostgreSQL之类的数据库存储。

0x04 : 心得体会

本文主要记录了下任务调度中,早期采用Celery的心路历程,并没有详细介绍Celery本身的框架细节,感觉在AI Coding时代,理解不同架构在不同业务场景下的优劣,平衡各方面的成本,才是比较重要的。